Facebook Status Box using Node.js and MySQL

A status box, like the one on Facebook, lets a user type an update and see it appear in a feed. This project builds one with Express handling the requests and MySQL saving each status so it’s still there after a reload.

I’ll walk you through saving each status from an Express form into a MySQL table, then reading the feed back.

What the status box does

A Facebook status box made with Node.js and MySQL has two jobs: accept text from a form and show saved rows in a feed. Express handles the requests, and MySQL keeps each row for a later page load.

I tested a status submission in the browser, and the next feed request returned that saved row. The interface borrows a familiar composer shape, and the app writes only to its own database.

StageWhat happens
Browser formSends the status text to Express with POST.
Express routeParses the form body and rejects blank or over-limit text.
MySQL insertStores the text as a parameter value in one row.
Feed requestReads saved rows in descending ID order.
Browser displayAdds each status as text in the feed.

The database keeps a status between requests, so Express can fetch it after a page reload.

Create the project and MySQL table

The executable sample ran on Node.js 26.10.0 against an isolated MariaDB 10.11.14 server, and no Oracle MySQL server was tested.

The Express 5 API reference documents URL-encoded request parsing and the Node.js requirement. The mysql2 Quickstart shows the promise interface and prepared statements, and the MySQL InnoDB table reference documents the table syntax used here.

Install the packages without adding fixed versions or a lockfile. The package manager resolves the registry versions when you run the command.

npm install --no-save --package-lock=false express mysql2 dotenv
cp .env.example .env

The table gives each status an automatically assigned ID and a creation timestamp. The feed orders by ID descending so a new insert appears first.

CREATE DATABASE status_box
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE status_box;

CREATE TABLE statuses (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  status_text TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE USER 'status_app'@'127.0.0.1' IDENTIFIED BY 'replace-with-a-local-password';
GRANT SELECT, INSERT ON status_box.statuses TO 'status_app'@'127.0.0.1';

I gave the application account only SELECT and INSERT because its two routes never update or delete rows.

DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=status_app
DB_PASSWORD=replace-with-a-local-password
DB_NAME=status_box
PORT=3101

Keep the local environment file out of version control so its database password stays in the machine’s configuration.

node_modules/
.env

Build the form and Express routes

The table assigns one job to each project file. The status field moves from the form to Express before it reaches MySQL.

FileResponsibility
public/index.htmlLabels the text area and loads the browser script.
public/app.jsSends a status and renders rows returned by Express.
public/styles.cssStyles the form and saved feed.
server.jsParses requests, validates text and runs MySQL queries.

Step 1. Add the status form

Name the field status so the Express route can read the submitted value. The required attribute catches an empty field in the browser.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Status box demo</title>
    <link rel="stylesheet" href="/styles.css">
    <script src="/app.js" defer></script>
  </head>
  <body>
    <main class="page">
      <h1>Status box</h1>
      <p class="note">A small Express and MySQL example. Posts are visible to anyone who can reach this demo.</p>
      <form id="status-form">
        <label for="status">What are you working on?</label>
        <textarea id="status" name="status" rows="4" maxlength="280" required></textarea>
        <button type="submit">Post status</button>
        <p id="message" role="status" aria-live="polite"></p>
      </form>
      <section aria-labelledby="feed-title">
        <h2 id="feed-title">Recent statuses</h2>
        <ol id="feed"></ol>
      </section>
    </main>
  </body>
</html>

The label gives the text area an accessible name, and the message region announces save or error feedback.

The separate stylesheet controls the page layout and the spacing between saved rows. Save it as public/styles.css.

* { box-sizing: border-box; }
body { margin: 0; background: #edf2f7; color: #172b4d; font: 16px/1.5 system-ui, sans-serif; }
.page { max-width: 720px; margin: 3rem auto; padding: 1.5rem; }
h1, h2 { line-height: 1.2; }
form, #feed li { padding: 1rem; border: 1px solid #cbd5e1; border-radius: 0.5rem; background: #fff; }
label, textarea { display: block; width: 100%; }
textarea { margin: 0.5rem 0 1rem; padding: 0.75rem; font: inherit; }
button { border: 0; border-radius: 0.35rem; padding: 0.65rem 1rem; background: #1664c0; color: white; font: inherit; cursor: pointer; }
#message { min-height: 1.5rem; }
#feed { display: grid; gap: 0.75rem; padding: 0; list-style: none; }
#feed li p { margin-top: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
#feed time, .note { color: #52637a; font-size: 0.9rem; }

The pre-wrap rule keeps line breaks in a multiline status, and overflow-wrap lets long text fit inside its card.

Step 2. Send the form and render the feed

The page requests saved rows when it opens. On submit, the script sends URL-encoded form data to Express, so the later GET can rebuild the feed from MySQL.

const form = document.querySelector('#status-form');
const input = document.querySelector('#status');
const feed = document.querySelector('#feed');
const message = document.querySelector('#message');

async function loadStatuses() {
  const response = await fetch('/api/statuses');
  if (!response.ok) throw new Error('Could not load statuses.');

  const statuses = await response.json();
  feed.replaceChildren();

  for (const status of statuses) {
    const item = document.createElement('li');
    const text = document.createElement('p');
    const time = document.createElement('time');
    text.textContent = status.status_text;
    time.textContent = `${new Intl.DateTimeFormat('en-IN', {
      dateStyle: 'medium',
      timeStyle: 'short',
      timeZone: 'Asia/Kolkata'
    }).format(new Date(status.created_at))} IST`;
    item.append(text, time);
    feed.append(item);
  }
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  message.textContent = 'Saving status…';

  try {
    const response = await fetch('/api/statuses', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ status: input.value })
    });
    const result = await response.json();
    if (!response.ok) throw new Error(result.error);

    form.reset();
    message.textContent = 'Status saved.';
    try {
      await loadStatuses();
    } catch (error) {
      message.textContent = 'Status saved, but the feed could not reload. Refresh the page to see it.';
    }
  } catch (error) {
    message.textContent = error.message;
  }
});

loadStatuses().catch((error) => {
  message.textContent = error.message;
});

If a successful POST is followed by a failed GET, the message says the status is saved and suggests a page refresh, so a second submission is not the only recovery.

The browser writes each status with the textContent property, so submitted tags remain text. MDN distinguishes this behavior from the innerHTML property.

The sample displays timestamps in India Standard Time (IST) by setting Asia/Kolkata. Change that zone if your audience uses another time.

Step 3. Parse, validate and store each status

Install the URL-encoded form parser before the routes so it fills req.body before the POST handler reads the status field.

require('dotenv').config();

const express = require('express');
const mysql = require('mysql2/promise');
const path = require('node:path');

const app = express();
const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT || 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 5
});

app.use(express.urlencoded({ extended: false, limit: '2kb' }));
app.use(express.static(path.join(__dirname, 'public')));

app.get('/api/statuses', async (req, res, next) => {
  try {
    const [rows] = await pool.execute(
      'SELECT id, status_text, created_at FROM statuses ORDER BY id DESC LIMIT 50'
    );
    res.json(rows);
  } catch (error) {
    next(error);
  }
});

app.post('/api/statuses', async (req, res, next) => {
  const status = typeof req.body.status === 'string' ? req.body.status.trim() : '';

  if (!status || status.length > 280) {
    return res.status(400).json({ error: 'Enter a status from 1 to 280 characters.' });
  }

  try {
    const [result] = await pool.execute(
      'INSERT INTO statuses (status_text) VALUES (?)',
      [status]
    );
    res.status(201).json({ id: result.insertId });
  } catch (error) {
    next(error);
  }
});

app.use((error, req, res, next) => {
  console.error('Status storage request failed:', error.code || error.name);
  res.status(503).json({ error: 'Status storage is unavailable. Check the MySQL connection.' });
});

const port = Number(process.env.PORT || 3000);
app.listen(port, () => console.log(`Status box listening on port ${port}`));

I used execute with a question-mark placeholder and a separate parameter array, so submitted text stays a value instead of changing the query. I set a 50-row cap on the GET route to keep the feed response bounded.

The insert returns the new identifier with status 201.

If you want more background on Express routing, CodeForGeek’s Express and Node.js tutorial covers the app and route basics used here.

Run the feed and see persistence

Set the database password in .env, then start the server. The console prints its selected port after Express begins listening.

node server.js

The application programming interface (API) returns status 201 over the Hypertext Transfer Protocol (HTTP) and includes the new row identifier (ID).

curl -sS -w '\nHTTP %{http_code}\n' -X POST http://localhost:3101/api/statuses -H 'Content-Type: application/x-www-form-urlencoded' --data 'status=This status survived a page reload.'
Terminal showing a successful status POST with HTTP 201 and an inserted row ID
Express accepts the form submission and returns HTTP 201 with the inserted row ID.

Open http://localhost:3101 to see the row returned from MySQL. A second browser submission appears above the earlier row with an IST timestamp.

Status box composer with two saved status rows and timestamps in IST
The browser reads status rows back from MySQL and shows their saved times in IST.

Reloading the page fetches the same text from MySQL, while a status held only in the document would disappear.

Fix the common insert failures

Each response points to a different layer, from a missing form value to a database connection refusal. An empty field gets caught by the browser, and Express returns 400 if a direct request skips that check.

SymptomCheckNext action
req.body.status is empty or missingParser order and the form field nameKeep express.urlencoded before the routes and name the text area status.
Response status 400Blank input or more than 280 JavaScript code unitsTrim the text and shorten it before posting.
Response status 503Database host, port, account grant and service stateRestore the MySQL connection, then request the feed again.

The browser’s required check catches a blank field, and Express also returns 400 when a direct request skips that check.

curl -sS -w '\nHTTP %{http_code}\n' -X POST http://localhost:3101/api/statuses -d 'status='
Terminal showing a 400 response for an empty status submission
An empty POST returns HTTP 400 with a validation message before the insert reaches MySQL.

A refused MySQL connection returns HTTP 503 with a generic message, so the browser can report an outage without echoing connection details. The server logs only the driver code.

curl --silent --show-error --max-time 10 -w '\nHTTP %{http_code}\n' http://localhost:3101/api/statuses
Terminal showing the status API 503 response while MySQL is unavailable
When MySQL refuses a connection, Express returns HTTP 503 instead of reporting a saved status.

The 280-character limit counts JavaScript string units, so some symbols count as two and can make the route reject text the form accepted. An emoji can use two units even when it looks like one symbol.

Keep the demo’s trust boundary

Every visitor who can reach the app reads the same feed, so add authentication and a user ID before you treat any row as private.

Private posts need an authenticated owner on every row, and every read and insert must enforce that owner. A login screen alone does not limit which rows a user can fetch.

curl -sS http://localhost:3101/api/statuses

Frequently asked questions

These answers mark the boundary between this local status feed and a site that handles user accounts or publishes to an external service.

Does this status box publish to Facebook?

No. It inserts the text into the status_box database and displays the row in this app. It has no Facebook login or publishing request.

Do statuses survive when Node.js restarts?

Yes, an inserted row remains in MySQL while the same database and its data are available. Express reads the feed from MySQL instead of keeping statuses in process memory.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337